import { FlowInstance, FlowInstanceInfo, FlowVariables } from './instance';
import { WorkflowManager } from './manager';
import { FlowNode, FlowNodeInfo } from './node';
import { UserPlayer } from './player';
import { Workflow, WorkflowError } from './workflow';

export interface WorkflowInfo {
	readonly id: string;
	readonly name: string;
	readonly version: string;
	readonly nodes: FlowNodeInfo[];
}

export interface InputInfo {
	player: UserPlayer;
	nodeId: string;
	nodeAppearId: number;
	actionId: string;
	data?: unknown;
	opinion?: string;
	/**
	 * Players for next arrive node (may send todo notify to them),
	 * if undefined or empty means this is a pre-caculation for next candidate players
	 */
	choosedPlayers?: Record<string, UserPlayer[]>;
	/**
	 * Whether is a testing for choose candidates
	 */
	test: boolean;
}

export interface BaseContext {
	/**
	 * A readonly workflow definition copy;
	 */
	readonly workflow: WorkflowInfo;
	/**
	 * A readonly instance copy, any changes of this field will be ignored.
	 */
	readonly instance: FlowInstanceInfo;
	/**
	 * Runtime extra info
	 */
	readonly extra: unknown;
	/**
	 * The previous node id list for current node,
	 */
	readonly previousNodeId: string[];
}

export interface ConditionContext {
	/**
	 * Current input
	 */
	readonly input: InputInfo;
}

export interface FlowContext extends BaseContext {
	/**
	 * Change flow data of current instance (completly replace,
	 * if want to change some part of the data then you should get current data, modify it and set it back)
	 * @param data flowData
	 */
	setData(data: unknown): void;
	/**
	 * replace current node variables
	 * @param variables current variables will be completly replaced
	 */
	setNodeVariable(variables: FlowVariables): void;
	/**
	 * set current node variable
	 * @param key variable key
	 * @param value variable value
	 */
	setNodeVariable(key: string, value: unknown): void;
	/**
	 * replace flow variables
	 * @param variables workflow variables will be completly replaced
	 */
	setFlowVariable(variables: FlowVariables): void;
	/**
	 * set workflow variable
	 * @param key variable key
	 * @param value variable value
	 */
	setFlowVariable(key: string, value: unknown): void;
	/**
	 * Workflow state move to next one or more node, after this method are invoked any methods of this context cannot be invoke again.
	 * Every call of this method will generate an approval history record even not specify any next nodes(current node's appearId will increment 1),
	 * Approval history record will save [context.input] as detail,
	 * if you want record different info you can change [context.input]'s content before you call this method.
	 * @param input Provide user or automatic input info for record history
	 * @param nexNodes One or more nodes (id only or with both modify node variables)
	 */
	go(input: InputInfo, ...nexNodes: (string | GotoNode)[]): Promise<void>;
}

export interface ArriveContext extends FlowContext {
	/**
	 * The variables received from previous node (the parameter passed to 'goto' method of ActionContext)
	 */
	readonly receivedVariables: FlowVariables | undefined;
	readonly choosedPlayers: UserPlayer[];
}

interface GotoNode {
	id: string;
	variables?: FlowVariables;
}

export interface ActionContext extends FlowContext {
	readonly input: InputInfo;
}

function findPreviousNode(inst: FlowInstance, nodeId: string, appearId: number): string[] {
	return inst.records
		.filter((r) => {
			return r.nextNodes.some((n) => {
				return n.id === nodeId && n.appearId === appearId;
			});
		})
		.map((r) => r.nodeId);
}

export class BaseActionHandler implements BaseContext {
	readonly workflow: WorkflowInfo;
	readonly instance: FlowInstance;
	readonly previousNodeId: string[];
	constructor(
		flow: Workflow,
		inst: FlowInstance,
		public nodeId: string,
		public appearId: number,
		public extra: unknown
	) {
		this.workflow = Workflow.toWorkflowInfo(flow);
		this.instance = JSON.parse(JSON.stringify(inst));
		this.previousNodeId = findPreviousNode(inst, nodeId, appearId);
		Object.defineProperties(this, {
			instance: {
				writable: false,
				configurable: false,
			},
			workflow: {
				writable: false,
				configurable: false,
			},
			previousNodeId: {
				writable: false,
				configurable: false,
			},
			input: {
				writable: false,
				configurable: false,
			},
			extra: {
				writable: false,
				configurable: false,
			},
		});
	}
}

export class ConditionHandler extends BaseActionHandler implements ConditionContext {
	constructor(
		flow: Workflow,
		inst: FlowInstance,
		nodeId: string,
		appearId: number,
		extra: unknown,
		public readonly input: InputInfo
	) {
		super(flow, inst, nodeId, appearId, extra);
		this.input = input;
		Object.defineProperty(this, 'input', {
			writable: false,
			configurable: false,
		});
	}
}

class ActionHandler extends BaseActionHandler implements FlowContext {
	setNodeVariable: (key: string | FlowVariables, value?: unknown) => void;
	setFlowVariable: (key: string | FlowVariables, value?: unknown) => void;
	setData: (data: unknown) => void;
	go: (input: InputInfo, ...nodes: (string | GotoNode)[]) => Promise<void>;

	constructor(
		manager: WorkflowManager,
		flow: Workflow,
		inst: FlowInstance,
		nodeId: string,
		appearId: number,
		extra: unknown
	) {
		super(flow, inst, nodeId, appearId, extra);
		const currentDataVersion = inst.dataVersion;
		function ensureCurrentNode() {
			if (!inst.currentNodes.find((n) => n.nodeId === nodeId && n.appearId === appearId)) {
				throw new WorkflowError(flow.id, `node [${nodeId}] currently is not the processing node`);
			}
		}
		this.setNodeVariable = (key: string | FlowVariables, value?: unknown): void => {
			ensureCurrentNode();
			if (typeof key === 'object') {
				inst.nodeVariables[`${nodeId}-${appearId}`] = key;
			} else if (typeof key === 'string') {
				inst.nodeVariables[`${nodeId}-${appearId}`][key] = value;
			}
		};
		this.setFlowVariable = (key: string | FlowVariables, value?: unknown): void => {
			ensureCurrentNode();
			if (typeof key === 'object') {
				inst.variables = key;
			} else if (typeof key === 'string') {
				inst.variables[key] = value;
			}
		};
		this.setData = (data: unknown): void => {
			ensureCurrentNode();
			inst.data = data;
			inst.dataVersion = currentDataVersion + 1;
		};
		this.go = async (input: InputInfo, ...nodes: (string | GotoNode)[]): Promise<void> => {
			ensureCurrentNode();
			if (nodes.length === 0) return;
			const allIds = flow.nodes.reduce((s, n) => (s.add(n.id), s), new Set<string>()); // all nodes id
			const goIds: string[] = [];
			nodes
				.map((n) => {
					if (typeof n === 'string') return n;
					return n.id;
				})
				.forEach((id) => {
					if (!allIds.has(id)) {
						throw new WorkflowError(
							flow.id,
							`cannot call 'go' method on node [${nodeId}]: target node [${id}] not exist`
						);
					}
					if (!goIds.includes(id)) {
						goIds.push(id);
					} else {
						throw new WorkflowError(
							flow.id,
							`cannot call 'go' method on node [${nodeId}]: given target node [${id}] duplicated`
						);
					}
				});
			const node = flow.getNode(nodeId);
			if (!FlowNode.isBeginOrNormal(node)) {
				throw new WorkflowError(
					flow.id,
					`cannot call 'go' method on node [${nodeId}]: only begin or normal node allowed`
				);
			}
			const action = node.getAction(input.actionId);
			if (
				action.condition &&
				!(await action.condition.match(new ConditionHandler(flow, inst, nodeId, appearId, extra, input)))
			) {
				throw new WorkflowError(
					flow.id,
					`cannot call 'go' method on node [${nodeId}]'s action [${input.actionId}]: condition not matched`
				);
			}
			goIds.forEach((id) => {
				if (!action.nextNodes.includes(id) && id !== nodeId) {
					throw new WorkflowError(
						flow.id,
						`cannot call 'go' method on node [${nodeId}]'s action [${input.actionId}]: node [${id}] not in allowed next node list`
					);
				}
			});
			if (goIds.length > 0) {
				goIds.forEach((id) => {
					const appearId = inst.nodeAppearId[id] ?? -1;
					inst.nodeAppearId[id] = appearId + 1;
				});
				// if no specify go ids means flow state no need change
				inst.currentNodes = goIds.map((id) => ({ nodeId: id, appearId: inst.nodeAppearId[id] }));
			}
			const choosedPlayers = input.choosedPlayers;
			// if no player choosed means it's a pre-caculation for get candidate players.
			if (choosedPlayers) {
				// check wether all node other to this node are has choosed players
				goIds
					.filter((id) => id !== nodeId) // ignore current node
					.forEach((id) => {
						const players = choosedPlayers[id];
						if (!players || players.length === 0) {
							throw new WorkflowError(
								flow.id,
								`cannot call 'go' method on node [${nodeId}]'s action [${input.actionId}]: target node [${id}] not specify player`
							);
						}
					});
				const nodeAppearId = inst.nodeAppearId[nodeId];
				inst.records.push({
					timestamp: Date.now(),
					nodeId: nodeId,
					nodeAppearId,
					actionId: input.actionId,
					player: input.player,
					dataVersion: inst.dataVersion,
					opinion: input.opinion,
					nextNodes: goIds.map((id) => ({ id: id, appearId: inst.nodeAppearId[id] })),
				});

				// remove all todo
				await manager.removeTodo({
					flowId: flow.id,
					flowVersion: flow.version,
					instanceId: inst.id,
					nodeId,
					nodeAppearId: appearId,
				});

				await Promise.all(
					nodes.map(async (n): Promise<void> => {
						const [id, receiveVariables] = typeof n === 'string' ? [n, undefined] : [n.id, n.variables];
						//inst, nodeId, appearId, choosedPlayers[id]
						const previousNodeName = this.previousNodeId.map((id) => flow.getNode(id).name).join(',');
						await manager.addTodo({
							flowId: flow.id,
							flowVersion: flow.version,
							instanceId: inst.id,
							flowName: flow.name,
							nodeName: node.name,
							previousNodeId: this.previousNodeId.join(','),
							previousNodeName,
							createTime: new Date(),
							nodeId,
							nodeAppearId: appearId,
							players: choosedPlayers[id],
						});
						await flow
							.getNode(id)
							.onArrive?.(
								new ArriveActionHandler(
									manager,
									flow,
									inst,
									id,
									inst.nodeAppearId[id],
									extra,
									receiveVariables,
									choosedPlayers[id]
								)
							);
					})
				);
			}
		};
	}
}

export class ArriveActionHandler extends ActionHandler implements ArriveContext {
	constructor(
		manager: WorkflowManager,
		flow: Workflow,
		inst: FlowInstance,
		nodeId: string,
		appearId: number,
		extra: unknown,
		public readonly receivedVariables: FlowVariables | undefined,
		public readonly choosedPlayers: UserPlayer[]
	) {
		super(manager, flow, inst, nodeId, appearId, extra);
		Object.defineProperties(this, {
			receivedVariables: {
				writable: false,
				configurable: false,
			},
			choosedPlayers: {
				writable: false,
				configurable: false,
			},
		});
	}
}

export class CommandActionHandler extends ActionHandler implements ActionContext {
	done: () => Promise<void>;

	constructor(
		manager: WorkflowManager,
		flow: Workflow,
		inst: FlowInstance,
		nodeId: string,
		appearId: number,
		extra: unknown,
		public readonly input: InputInfo
	) {
		super(manager, flow, inst, nodeId, appearId, extra);
		this.input = input;
		Object.defineProperty(this, 'input', {
			writable: false,
			configurable: false,
		});
		this.done = async (): Promise<void> => {
			await manager.removeTodo({
				flowId: flow.id,
				flowVersion: flow.version,
				instanceId: inst.id,
				nodeId: nodeId,
				nodeAppearId: appearId,
				players: [input.player],
			});
		};
	}
}
